{T}

API 用途分析

通过第 6 节和第 7 节文档的学习,我们掌握了如何分析 Import 节点并判定 API 调用,今天本篇我们主要讲解如何分析 API 的具体用途并记录相关调用信息,也就是 step5 的最后一小步。

我们的分析目标是,了解项目对于目标依赖真实的 API 调用情况,所以在分析每个代码文件时,需要在 API 调用判定成功后记录相关调用信息,这样在分析完所有文件后,才可以得到统计型的分析结果。

API 调用信息统计

要了解 API 调用情况,我们需要记录以下信息:

apiName: API 在代码中的完整调用名(Map key)

callNum: API 调用总次数

callOrigin: API 本名,通过 as 导入的API会存在此项

callFiles: API 调用分布情况(Map)

filePath: 存在 API 调用的代码文件路径信息(Map key)

projectName: 存在 API 调用的代码文件所在的项目

httpRepo: 用于在代码分析报告展示在线浏览代码文件的http链接前缀

lines: 代码文件中出现 API 调用的代码行信息(数组)

我们实现一个名为 isApiCheck 的函数,执行后会在 codeAnalysis 分析实例上挂载一个名为 apiMap 的对象,然后以 apiName 为 key ,用键值对的形式记录相关调用信息,具体代码如下:

typescript
const mapName = 'apiMap';

// context : codeAnalysis分析实例上下文
// tsCompiler : typescript编译器
// node : 基准分析节点baseNode
// depth : 链式调用深度
// apiName : api完整调用名(含链式调用)
// matchImportItem : API调用在import节点中的声明信息
// filePath : 代码文件路径
// projectName : 待分析代码文件所在的项目名称
// line : API调用所在代码文件中的行信息
function isApiCheck (context, tsCompiler, node, depth, apiName, matchImportItem, filePath, projectName, httpRepo, line) {
    if (!context[mapName][apiName]) {
        context[mapName][apiName] = {};
        context[mapName][apiName].callNum = 1;
        context[mapName][apiName].callOrigin = matchImportItem.origin;
        context[mapName][apiName].callFiles = {};
        context[mapName][apiName].callFiles[filePath] = {};
        context[mapName][apiName].callFiles[filePath].projectName = projectName;
        context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
        context[mapName][apiName].callFiles[filePath].lines = [];
        context[mapName][apiName].callFiles[filePath].lines.push(line);
    } else {
        context[mapName][apiName].callNum++;
        if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
            context[mapName][apiName].callFiles[filePath] = {};
            context[mapName][apiName].callFiles[filePath].projectName = projectName;
            context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
            context[mapName][apiName].callFiles[filePath].lines = [];
            context[mapName][apiName].callFiles[filePath].lines.push(line);
        }else{
            context[mapName][apiName].callFiles[filePath].lines.push(line);
        }
    }
}

那么应该在什么时候记录呢?对,在 _dealAST 函数判定 API 调用成功后(即在 step5 第二个小步骤判定完成后)执行 isApiCheck 记录相关调用信息,简化版演示代码如下:

typescript
_dealAST() {
    ......
    const that = this;
    // 遍历AST
    function walk(node) {
        ......
        tsCompiler.forEachChild(node, walk);
        // 获取基础分析节点信息
        const { baseNode, depth, apiName } = that._checkPropertyAccess();

        // API调用信息统计
        isApiCheck(baseNode, depth, apiName, ...)
        ......
    }
    walk(ast);
    ......
}

最后在全部代码文件分析完成后,通过 apiMap 属性即可获取分析数据,举个例子:

json
"apiMap": {
    "loader": {
        "callNum": 3,
        "callOrigin": null,
        "callFiles": {
            "Order&src/pages/BasicSettings.vue": {
                "projectName": "Order",
                "httpRepo": "",
                "lines": [
                    84,
                    97
                ]
            },
            "Order&src/pages/Index.vue": {
                "projectName": "Order",
                "httpRepo": "",
                "lines": [
                    32
                ]
            }
        }
    },
    "Util.getUser": {
        "callNum": 1,
        "callOrigin": null,
        "callFiles": {
            "Order&src/api/index.ts": {
                "projectName": "Order",
                "httpRepo": "",
                "lines": [
                    13
                ]
            }
        }
    }
}

通过 apiMap,我们了解到从 framework 中导入并使用了 loaderUtil 这两个 API。其中 loader 被调用了 3 次,分别位于 src/pages/BasicSettings.vue 文件中的 8497 行,以及 src/pages/Index.vue 文件中的 32 行。Util 以链式调用 Util.getUser 的方式被调用了 1 次,位于 src/api/index.ts 文件中的 13 行。

但是我们还不清楚这些 API 是 方法,还是类型,或是只读属性,又或是其它类型,想要对 API 进行用途分析,可以先来观察下 API 在不同用途场景中 AST 的结构特征:

Method API 分析

typescript
import { loader, app } from 'framework';

loader('user');
app.localStorage.set('store', 'iceman');

将上述代码放入 TypeScript AST Viewer,通过观察 AST 我们发现:

可以通过判断 baseNode 基准节点的父级节点是否为 CallExpression 类型来判定 API 是否属于 Method API,不过需要排除一种干扰因素,举个例子:

typescript
import { loader, app } from 'framework';

loader('user');
app.localStorage.set('store', 'iceman');
getUserInfo(app.info);    // 需要排除被当成方法入参被调用的场景

将上面的代码放入 TypeScript AST Viewer

观察后可以发现虽然 baseNode 基准节点的父级节点是 CallExpression 类型,但此时 API 是被当成方法入参被调用的。CallExpression 类型节点的 expression 属性表示方法名,arguments 属性表示方法入参,所以判断 API 是否属于方法调用,除了判断父级节点是否为 CallExpression 类型外,还需要保证 baseNode 在父节点的 expression 属性中而非 arguments 属性中,这一点可以通过判断父节点 expression 属性的 pos 和 end 值与基准节点的 pos 和 end 值是否一致来判定。因此,Method API 检测的分析代码这样实现:

typescript
const mapName = 'methodMap';

// context : codeAnalysis分析实例上下文
// tsCompiler : typescript编译器
// node : 基准分析节点baseNode
// depth : 链式调用深度
// apiName : api完整调用名
// matchImportItem : API调用在import节点中的声明信息
// filePath : 代码文件路径
// projectName : 待分析代码文件所在的项目名称
// line : API调用所在代码文件中的行信息
function isMethodCheck (context, tsCompiler, node, depth, apiName, matchImportItem, filePath, projectName, httpRepo, line) {
    if(node.parent && tsCompiler.isCallExpression(node.parent)){ // 存在于函数调用表达式中
        if(node.parent.expression.pos == node.pos
            && node.parent.expression.end == node.end){  // 命中函数名method检测
            if (!context[mapName][apiName]) {
                context[mapName][apiName] = {};
                context[mapName][apiName].callNum = 1;
                context[mapName][apiName].callOrigin = matchImportItem.origin;
                context[mapName][apiName].callFiles = {};
                context[mapName][apiName].callFiles[filePath] = {};
                context[mapName][apiName].callFiles[filePath].projectName = projectName;
                context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                context[mapName][apiName].callFiles[filePath].lines = [];
                context[mapName][apiName].callFiles[filePath].lines.push(line);
            } else {
                context[mapName][apiName].callNum++;
                if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
                    context[mapName][apiName].callFiles[filePath] = {};
                    context[mapName][apiName].callFiles[filePath].projectName = projectName;
                    context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                    context[mapName][apiName].callFiles[filePath].lines = [];
                    context[mapName][apiName].callFiles[filePath].lines.push(line);
                }else{
                    context[mapName][apiName].callFiles[filePath].lines.push(line);
                }
            }
        }
    }
}

isMethodCheck 函数在判定 API 属于 Method API 后,会将相关的信息记录在 codeAnalysis 实例的 methodMap 属性中,这样在分析完所有代码文件后,可以通过 methodMap 了解到项目从目标依赖中引入了哪些方法 API,以及它们的具体调用信息。

Type API 分析

typescript
import { RequestType } from 'framework';

const data : RequestType = { show : 2 };
const arr : Array<RequestType> = [];

同理,将上述代码放入 TypeScript AST Viewer,通过观察 AST 我们发现:

不论是普通类型还是泛型,只需要判断基准节点 baseNode 的父级节点是否为 TypeReference 类型,即可判定 API 是否属于 Type API,检测的分析代码这样实现:

typescript
const mapName = 'typeMap';

// context : codeAnalysis分析实例上下文
// tsCompiler : typescript编译器
// node : 基准分析节点baseNode
// depth : 链式调用深度
// apiName : api完整调用名
// matchImportItem : API调用在import节点中的声明信息
// filePath : 代码文件路径
// projectName : 待分析代码文件所在的项目名称
// line : API调用所在代码文件中的行信息
function isTypeCheck (context, tsCompiler, node, depth, apiName, matchImportItem, filePath, projectName, httpRepo, line) {
    if(node.parent && tsCompiler.isTypeReferenceNode(node.parent)){                        // 命中Type检测
        if (!context[mapName][apiName]) {
            context[mapName][apiName] = {};
            context[mapName][apiName].callNum = 1;
            context[mapName][apiName].callOrigin = matchImportItem.origin;
            context[mapName][apiName].callFiles = {};
            context[mapName][apiName].callFiles[filePath] = {};
            context[mapName][apiName].callFiles[filePath].projectName = projectName;
            context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
            context[mapName][apiName].callFiles[filePath].lines = [];
            context[mapName][apiName].callFiles[filePath].lines.push(line);
        } else {
            context[mapName][apiName].callNum++;
            if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
                context[mapName][apiName].callFiles[filePath] = {};
                context[mapName][apiName].callFiles[filePath].projectName = projectName;
                context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                context[mapName][apiName].callFiles[filePath].lines = [];
                context[mapName][apiName].callFiles[filePath].lines.push(line);
            }else{
                context[mapName][apiName].callFiles[filePath].lines.push(line);
            }
        }
    }
}

同样,在分析完所有代码文件后,我们可以通过 typeMap 了解到项目从目标依赖中导入了哪些类型 API ,以及它们的具体调用信息。

代码结构问题

上述用途分析的策略都是将 baseNode 基准节点以及相关信息作为入参来执行一个判定函数,然后将满足判定条件的 API 及调用信息记录到 Map 结构中,等全部文件分析完成后即可从特定 Map 中获取分析结果。

typescript
_dealAST() {
    ......
    const that = this;
    // 遍历AST
    function walk(node) {
        ......
        tsCompiler.forEachChild(node, walk);
        // 获取基础分析节点信息
        const { baseNode, depth, apiName } = that._checkPropertyAccess();

        // API 调用各种分析指标的判定函数
        isApiCheck(baseNode, depth, apiName, ...)
        isMethodCheck(baseNode, depth, apiName, ...)
        isTypeCheck(baseNode, depth, apiName, ...)
        ......
    }
    walk(ast);
    ......
}

按照这种思路,每实现一种分析指标就需要在 _dealAST 中添加一个新的判定函数,这么做会有什么问题呢?

可维护性变差

_dealAST 函数是 codeAnalysis 基础类执行分析时遍历 AST 的核心方法,按照目前的代码结构及分析策略,新增判定函数会让 analysis 模块越来越臃肿,同时判定函数需要不断地调试并验证其准确性,这就意味着后续迭代需要不断的变更 analysis 模块,可维护性也逐步变差。

执行性能变差

如果一个 API 属于 Method API ,那它就不会是 Type API,也就是说用途判定具有 互斥性,即一个 API 如果属于方法调用,那么它在执行并命中 Method API 的判定函数后就没必要执行其它判定函数了。

但是目前的代码结构导致每个 baseNode 基准节点都必须依次执行所有判定函数,且无法动态调整各个判定函数的执行次序,代码的执行性能很差。

那么如何解决这些问题呢?下一节课我们会学习如何通过插件方案来解决主程序与子程序之间的代码结构问题。

小结

这一小节我们学习了如何实现 API 用途分析,需要大家掌握以下知识点:

  1. 想要了解项目对于目标依赖真实的 API 调用情况,需要在分析每个代码文件时记录 API 调用的相关信息,这样在所有文件分析完成后,才可以得到统计型的分析结果。
  2. 可以通过判断基准节点的父级节点是否为 CallExpression 类型来判定 API 是否属于方法调用,不过需要排除被当成方法入参调用的场景。
  3. 不论是普通类型还是泛型,都可以通过判断其基准节点的父级节点是否为 TypeReference 类型来判定该 API 是否属于类型调用。
  4. 目前的代码结构及分析策略会导致 analysis 模块越来越臃肿,可维护性变差,每个基准节点必须依次执行所有判定函数,代码执行性能很差。